package manager

import (
	"context"
	"errors"
	"crypto/md5"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"sort"
	"path/filepath"
	"strings"
	"os"
	"sync"
	"time"
	"regexp"

	"downloadmanager/internal/config"
	"downloadmanager/internal/downloader"
	"downloadmanager/internal/bittorrent"
)

type Status int

const (
	StatusWaiting Status = iota
	StatusRunning
	StatusPaused
	StatusCompleted
	StatusFailed
)

type Priority int

const (
	PriorityHigh Priority = iota
	PriorityMedium
	PriorityLow
)

type Task struct {
	ID           string
	URL          string
	Sources      []string // optional additional mirror URLs; URL is the primary
	SavePath     string
	Status       Status
	Priority     Priority
	Progress     downloader.Progress
	Err          error
	ChecksumAlgo string // ""|"MD5"|"SHA256"
	ChecksumValue string // hex lowercase/uppercase both accepted
	CreatedAt    time.Time
	UpdatedAt    time.Time
	cancel       context.CancelFunc
	Attempts     int
	NextTryAt    time.Time
	StartAt      time.Time
	Switches     int
	// per-task overrides from rules (0/empty means no override)
	ChunksOverride           int
	MaxRetriesOverride       int
	RetryBackoffSecOverride  int
	SpeedLimitBytesOverride  int64
	ProxyURLOverride         string
	// BT-only: selected file indices to download; empty means all
	SelectedFiles            []int
}

// renderSaveSubdir applies template variables to SaveSubdir and returns a safe relative path
// Supported variables: {category}, {ext}, {date}
func renderSaveSubdir(tpl, filename string) string {
    if strings.TrimSpace(tpl) == "" { return "" }
    cat := categorize(filename)
    ext := strings.ToLower(filepath.Ext(filename))
    date := time.Now().Format("2006-01-02")
    rep := strings.ReplaceAll
    out := tpl
    out = rep(out, "{category}", cat)
    out = rep(out, "{ext}", ext)
    out = rep(out, "{date}", date)
    clean := filepath.Clean(out)
    // forbid absolute or path escape
    if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) { return "" }
    // normalize separators
    return clean
}

// ReapplyRules applies current rules to an existing task (MVP: only updates priority)
func (m *Manager) ReapplyRules(id string) error {
    m.mu.Lock()
    defer m.mu.Unlock()
    t, ok := m.tasks[id]
    if !ok { return errors.New("task not found") }
    base := filepath.Base(t.SavePath)
    _, newPr, _ := m.applyRules(t.URL, base, t.Priority)
    if newPr != t.Priority {
        t.Priority = newPr
        t.UpdatedAt = time.Now()
        go m.schedule()
    }
    return nil
}

// applyRules applies first-match rule, returning (save_subdir, newPriority, actions)
func (m *Manager) applyRules(url, filename string, cur Priority) (string, Priority, *config.RuleActions) {
    rules := m.cfg.Rules
    if len(rules) == 0 { return "", cur, nil }
    lowExt := strings.ToLower(filepath.Ext(filename))
    cat := categorize(filename)
    for _, r := range rules {
        if !r.Enabled { continue }
        // conditions
        matched := true
        if len(r.Cond.URLContains) > 0 {
            ok := false
            lowURL := strings.ToLower(url)
            for _, sub := range r.Cond.URLContains { if sub != "" && strings.Contains(lowURL, strings.ToLower(sub)) { ok = true; break } }
            if !ok { matched = false }
        }
        if matched && len(r.Cond.ExtIn) > 0 {
            ok := false
            for _, e := range r.Cond.ExtIn { if strings.ToLower(strings.TrimSpace(e)) == lowExt { ok = true; break } }
            if !ok { matched = false }
        }
        if matched && strings.TrimSpace(r.Cond.NameRegex) != "" {
            re, err := regexp.Compile(r.Cond.NameRegex)
            if err != nil || !re.MatchString(filename) { matched = false }
        }
        if matched && len(r.Cond.CategoryIs) > 0 {
            ok := false
            for _, c := range r.Cond.CategoryIs { if strings.EqualFold(strings.TrimSpace(c), cat) { ok = true; break } }
            if !ok { matched = false }
        }
        if !matched { continue }
        // actions
        newPr := cur
        switch strings.ToLower(strings.TrimSpace(r.Act.Priority)) {
        case "high": newPr = PriorityHigh
        case "low": newPr = PriorityLow
        case "medium": newPr = PriorityMedium
        }
        return r.Act.SaveSubdir, newPr, &r.Act
    }
    return "", cur, nil
}

// UpdateSettings applies new runtime settings and reconfigures downloader.
func (m *Manager) UpdateSettings(newCfg *config.Settings) {
	m.mu.Lock()
	m.cfg = newCfg
	m.dl = downloader.New(downloader.Options{
		SpeedLimitBytes: m.cfg.SpeedLimitBytes,
		Chunks:          m.cfg.ChunkCount,
		ProxyURL:        m.cfg.ProxyURL,
		MaxRetries:      m.cfg.MaxRetries,
		RetryBackoffSec: m.cfg.RetryBackoffSec,
	})
	m.mu.Unlock()
	// re-schedule to respect new MaxConcurrent etc.
	go m.schedule()
}

func (m *Manager) UpdatePriority(id string, pr Priority) error {
    m.mu.Lock()
    defer m.mu.Unlock()
    t, ok := m.tasks[id]
    if !ok { return errors.New("task not found") }
    if t.Priority == pr { return nil }
    t.Priority = pr
    t.UpdatedAt = time.Now()
    go m.schedule()
    return nil
}

type Manager struct {
	cfg   *config.Settings
	mu    sync.RWMutex
	tasks map[string]*Task
	dl    *downloader.Downloader
	running int
}

func NewManager(cfg *config.Settings) *Manager {
	return &Manager{
		cfg:   cfg,
		mu:    sync.RWMutex{},
		tasks: map[string]*Task{},
		dl:    downloader.New(downloader.Options{SpeedLimitBytes: cfg.SpeedLimitBytes, Chunks: cfg.ChunkCount, ProxyURL: cfg.ProxyURL, MaxRetries: cfg.MaxRetries, RetryBackoffSec: cfg.RetryBackoffSec}),
		running: 0,
	}
}

func (m *Manager) Add(url, filename string) *Task { return m.AddWithPriority(url, filename, PriorityMedium) }

func (m *Manager) AddWithPriority(url, filename string, pr Priority) *Task {
    m.mu.Lock()
    defer m.mu.Unlock()
    id := time.Now().Format("20060102150405.000")
    saveDir := m.cfg.DefaultSaveDir
    if m.cfg.AutoCategorize {
        if cat := categorize(filename); cat != "" {
            saveDir = filepath.Join(saveDir, cat)
        }
    }
    // apply first-match rule
    sd, newPr, act := m.applyRules(url, filename, pr)
    if newPr >= PriorityHigh && newPr <= PriorityLow { pr = newPr }
    if strings.TrimSpace(sd) != "" {
        if clean := renderSaveSubdir(sd, filename); clean != "" { saveDir = filepath.Join(saveDir, clean) }
    }
    save := filepath.Join(saveDir, filename)
    t := &Task{ID: id, URL: url, Sources: nil, SavePath: save, Status: StatusWaiting, Priority: pr, CreatedAt: time.Now(), UpdatedAt: time.Now(), Attempts: 0}
    if act != nil {
        t.ChunksOverride = act.Chunks
        t.MaxRetriesOverride = act.MaxRetries
        t.RetryBackoffSecOverride = act.RetryBackoffSec
        if act.SpeedLimitMBps > 0 { t.SpeedLimitBytesOverride = int64(act.SpeedLimitMBps) * 1024 * 1024 }
        t.ProxyURLOverride = strings.TrimSpace(act.ProxyURL)
    }
    m.tasks[id] = t
    // try schedule new task
    go m.schedule()
    return t
}

// AddWithSources adds a task with multiple mirror URLs. The first URL will be used as primary.
func (m *Manager) AddWithSources(urls []string, filename string, pr Priority) *Task {
    m.mu.Lock()
    defer m.mu.Unlock()
    id := time.Now().Format("20060102150405.000")
    saveDir := m.cfg.DefaultSaveDir
    if m.cfg.AutoCategorize {
        if cat := categorize(filename); cat != "" {
            saveDir = filepath.Join(saveDir, cat)
        }
    }
    // determine primary and mirrors first
    primary := ""
    mirrors := []string{}
    for _, u := range urls {
        u = strings.TrimSpace(u)
        if u == "" { continue }
        if primary == "" { primary = u } else { mirrors = append(mirrors, u) }
    }
    if primary == "" { primary = urls[0] }
    // apply first-match rule with primary URL
    sd, newPr, act := m.applyRules(primary, filename, pr)
    if newPr >= PriorityHigh && newPr <= PriorityLow { pr = newPr }
    if strings.TrimSpace(sd) != "" {
        clean := filepath.Clean(sd)
        if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) { clean = "" }
        if clean != "" { saveDir = filepath.Join(saveDir, clean) }
    }
    save := filepath.Join(saveDir, filename)
    t := &Task{ID: id, URL: primary, Sources: mirrors, SavePath: save, Status: StatusWaiting, Priority: pr, CreatedAt: time.Now(), UpdatedAt: time.Now(), Attempts: 0}
    if act != nil {
        t.ChunksOverride = act.Chunks
        t.MaxRetriesOverride = act.MaxRetries
        t.RetryBackoffSecOverride = act.RetryBackoffSec
        if act.SpeedLimitMBps > 0 { t.SpeedLimitBytesOverride = int64(act.SpeedLimitMBps) * 1024 * 1024 }
        t.ProxyURLOverride = strings.TrimSpace(act.ProxyURL)
    }
    m.tasks[id] = t
    go m.schedule()
    return t
}

func (m *Manager) Start(id string, progress func(*Task)) error {
	m.mu.Lock()
	t, ok := m.tasks[id]
	if !ok { m.mu.Unlock(); return errors.New("task not found") }
	// Mark as waiting and let scheduler handle start order
	if t.Status == StatusCompleted || t.Status == StatusRunning { m.mu.Unlock(); return nil }
	t.Status = StatusWaiting
	t.UpdatedAt = time.Now()
	m.mu.Unlock()
	go m.scheduleWithProgress(progress)
	return nil
}

func (m *Manager) Pause(id string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	t, ok := m.tasks[id]
	if !ok { return errors.New("task not found") }
	if t.cancel != nil {
		t.cancel()
	}
	if t.Status == StatusRunning && m.running > 0 { m.running-- }
	t.Status = StatusPaused
	t.UpdatedAt = time.Now()
	go m.schedule()
	return nil
}

func (m *Manager) Remove(id string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	t, ok := m.tasks[id]
	if !ok { return errors.New("task not found") }
	if t.Status == StatusRunning && t.cancel != nil {
		t.cancel()
		if m.running > 0 { m.running-- }
	}
	delete(m.tasks, id)
	go m.schedule()
	return nil
}

func (m *Manager) List() []*Task {
    m.mu.RLock()
    defer m.mu.RUnlock()
    out := make([]*Task, 0, len(m.tasks))
    for _, t := range m.tasks { out = append(out, t) }
    return out
}

// SetBTSelectedFiles sets the selected file indices for a BT task. Call before Start.
func (m *Manager) SetBTSelectedFiles(id string, indices []int) error {
    m.mu.Lock()
    defer m.mu.Unlock()
    t, ok := m.tasks[id]
    if !ok { return errors.New("task not found") }
    t.SelectedFiles = append([]int{}, indices...)
    t.UpdatedAt = time.Now()
    return nil
}

// schedule starts waiting tasks by priority until reaching MaxConcurrent
func (m *Manager) schedule() {
    m.scheduleWithProgress(nil)
}

func (m *Manager) scheduleWithProgress(progress func(*Task)) {
    for {
        m.mu.Lock()
        max := m.cfg.MaxConcurrent
        if max <= 0 { max = 1 }
        if m.running >= max {
            m.mu.Unlock()
            return
        }
        // current running per category (for group limits)
        runningPerCat := map[string]int{}
        for _, t := range m.tasks {
            if t.Status == StatusRunning {
                cat := categorize(filepath.Base(t.SavePath))
                runningPerCat[cat]++
            }
        }
        // pick next waiting task by priority and createdAt, respecting group limits
        var cand *Task
        tasks := make([]*Task, 0, len(m.tasks))
        now := time.Now()
        for _, t := range m.tasks {
            if t.Status == StatusWaiting {
                // skip not yet due (retry backoff)
                if !t.NextTryAt.IsZero() && now.Before(t.NextTryAt) { continue }
                // skip not yet scheduled start time
                if !t.StartAt.IsZero() && now.Before(t.StartAt) { continue }
                // respect group limits (per category)
                cat := categorize(filepath.Base(t.SavePath))
                if lim, ok := m.cfg.GroupLimits[cat]; ok && lim > 0 {
                    if runningPerCat[cat] >= lim { continue }
                }
                tasks = append(tasks, t)
            }
        }
        sort.Slice(tasks, func(i, j int) bool {
            if tasks[i].Priority != tasks[j].Priority { return tasks[i].Priority < tasks[j].Priority }
            return tasks[i].CreatedAt.Before(tasks[j].CreatedAt)
        })
        if len(tasks) > 0 { cand = tasks[0] }
        if cand == nil {
            m.mu.Unlock()
            return
        }
        // update per-category running counter for selected candidate in this loop iteration
        selCat := categorize(filepath.Base(cand.SavePath))
        runningPerCat[selCat]++
        // start cand
        ctx, cancel := context.WithCancel(context.Background())
        cand.cancel = cancel
        cand.Status = StatusRunning
        cand.UpdatedAt = time.Now()
        cand.NextTryAt = time.Time{}
        // ensure parent directory exists
        _ = os.MkdirAll(filepath.Dir(cand.SavePath), 0o755)
        m.running++
        m.mu.Unlock()

        go func(task *Task) {
            // build per-task downloader with overrides applied
            opt := downloader.Options{
                SpeedLimitBytes: m.cfg.SpeedLimitBytes,
                Chunks:          m.cfg.ChunkCount,
                ProxyURL:        m.cfg.ProxyURL,
                MaxRetries:      m.cfg.MaxRetries,
                RetryBackoffSec: m.cfg.RetryBackoffSec,
            }
            if task.SpeedLimitBytesOverride > 0 { opt.SpeedLimitBytes = task.SpeedLimitBytesOverride }
            if task.ChunksOverride > 0 { opt.Chunks = task.ChunksOverride }
            if task.MaxRetriesOverride > 0 { opt.MaxRetries = task.MaxRetriesOverride }
            if task.RetryBackoffSecOverride > 0 { opt.RetryBackoffSec = task.RetryBackoffSecOverride }
            if task.ProxyURLOverride != "" { opt.ProxyURL = task.ProxyURLOverride }
            var err error
            if isBT(task.URL) || strings.HasSuffix(strings.ToLower(task.URL), ".torrent") {
                // derive save directory for torrent
                // - for magnet: use parent directory directly
                // - for .torrent: use filename without .torrent as subfolder
                saveDir := filepath.Dir(task.SavePath)
                if strings.HasPrefix(strings.ToLower(strings.TrimSpace(task.URL)), "magnet:") {
                    // keep saveDir as-is
                } else {
                    base := filepath.Base(task.SavePath)
                    low := strings.ToLower(base)
                    if strings.HasSuffix(low, ".torrent") { base = strings.TrimSuffix(base, ".torrent") }
                    if strings.TrimSpace(base) == "" { base = task.ID }
                    saveDir = filepath.Join(saveDir, base)
                }
                btd := bittorrent.New(bittorrent.Options{ NoUpload: m.cfg.BTNoUpload, ListenPort: m.cfg.BTListenPort, EnableDHT: m.cfg.BTEnableDHT, EnableUPnP: m.cfg.BTEnableUPnP, DisablePEX: m.cfg.BTDisablePEX, DisableTrackers: m.cfg.BTDisableTrackers, MaxPeersGlobal: m.cfg.BTMaxPeersGlobal, MaxPeersPerTorrent: m.cfg.BTMaxPeersPerTorrent })
                err = btd.Download(ctx, task.URL, saveDir, task.SelectedFiles, func(p downloader.Progress) {
                    m.mu.Lock()
                    task.Progress = p
                    task.UpdatedAt = time.Now()
                    m.mu.Unlock()
                    if progress != nil { progress(task) }
                })
            } else {
                dl := downloader.New(opt)
                err = dl.Download(ctx, task.URL, task.Sources, task.SavePath, func(p downloader.Progress) {
                    m.mu.Lock()
                    task.Progress = p
                    task.UpdatedAt = time.Now()
                    m.mu.Unlock()
                    if progress != nil { progress(task) }
                })
            }
            m.mu.Lock()
            if err != nil {
                if task.Status != StatusPaused {
                    task.Err = err
                    // retry if attempts not exceeded
                    if m.cfg.MaxRetries > 0 && task.Attempts < m.cfg.MaxRetries {
                        task.Attempts++
                        // simple exponential backoff based on attempts
                        backoff := time.Duration(m.cfg.RetryBackoffSec) * time.Second
                        if backoff <= 0 { backoff = 5 * time.Second }
                        delay := backoff << (task.Attempts - 1)
                        task.NextTryAt = time.Now().Add(delay)
                        task.Status = StatusWaiting
                    } else {
                        task.Status = StatusFailed
                    }
                }
            } else {
                // verify checksum if configured
                if strings.TrimSpace(task.ChecksumAlgo) != "" && strings.TrimSpace(task.ChecksumValue) != "" {
                    m.mu.Unlock()
                    verErr := verifyChecksum(task.SavePath, task.ChecksumAlgo, task.ChecksumValue)
                    m.mu.Lock()
                    if verErr != nil {
                        // mark as error to trigger retry and rotate source
                        task.Err = verErr
                        // rotate sources so next attempt prefers a different source
                        used := strings.TrimSpace(task.Progress.Source)
                        if used != "" {
                            rotateSources(task, used)
                        }
                        if m.cfg.MaxRetries > 0 && task.Attempts < m.cfg.MaxRetries {
                            task.Attempts++
                            backoff := time.Duration(m.cfg.RetryBackoffSec) * time.Second
                            if backoff <= 0 { backoff = 5 * time.Second }
                            delay := backoff << (task.Attempts - 1)
                            task.NextTryAt = time.Now().Add(delay)
                            task.Status = StatusWaiting
                        } else {
                            task.Status = StatusFailed
                        }
                    } else {
                        if task.Status != StatusPaused { task.Status = StatusCompleted }
                    }
                } else {
                    if task.Status != StatusPaused { task.Status = StatusCompleted }
                }
            }
            task.UpdatedAt = time.Now()
            if m.running > 0 { m.running-- }
            m.mu.Unlock()
            if progress != nil { progress(task) }
            // try schedule next
            m.scheduleWithProgress(progress)
        }(cand)
        // loop again in case there are more slots
    }
}

// isBT reports whether the url is a magnet link or a direct .torrent reference
func isBT(u string) bool {
    lu := strings.ToLower(strings.TrimSpace(u))
    if lu == "" { return false }
    if strings.HasPrefix(lu, "magnet:") { return true }
    if strings.HasSuffix(lu, ".torrent") { return true }
    return false
}

// SetChecksum sets checksum parameters for a task
func (m *Manager) SetChecksum(id, algo, value string) error {
    algo = strings.ToUpper(strings.TrimSpace(algo))
    value = strings.TrimSpace(value)
    if algo != "" && algo != "MD5" && algo != "SHA256" { return errors.New("unsupported checksum algo") }
    m.mu.Lock()
    defer m.mu.Unlock()
    t, ok := m.tasks[id]
    if !ok { return errors.New("task not found") }
    t.ChecksumAlgo = algo
    t.ChecksumValue = value
    t.UpdatedAt = time.Now()
    return nil
}

func verifyChecksum(path, algo, expectHex string) error {
    f, err := os.Open(path)
    if err != nil { return err }
    defer f.Close()
    var sum []byte
    switch strings.ToUpper(algo) {
    case "MD5":
        h := md5.New()
        if _, err := io.Copy(h, f); err != nil { return err }
        sum = h.Sum(nil)
    case "SHA256":
        h := sha256.New()
        if _, err := io.Copy(h, f); err != nil { return err }
        sum = h.Sum(nil)
    default:
        return nil
    }
    got := hex.EncodeToString(sum)
    exp := strings.ToLower(strings.TrimSpace(expectHex))
    if got != exp { return errors.New("checksum mismatch") }
    return nil
}

func rotateSources(t *Task, used string) {
    // if primary matches used, move it to end and promote first mirror
    if strings.TrimSpace(t.URL) == used {
        if len(t.Sources) > 0 {
            t.Sources = append(t.Sources, t.URL)
            t.URL = t.Sources[0]
            t.Sources = t.Sources[1:]
            t.Switches++
        }
        return
    }
    // find in mirrors and move it to end
    for i, s := range t.Sources {
        if strings.TrimSpace(s) == used {
            // remove i, append to end
            t.Sources = append(append([]string{}, t.Sources[:i]...), t.Sources[i+1:]...)
            t.Sources = append(t.Sources, s)
            t.Switches++
            return
        }
    }
}

// categorize returns a subdirectory name based on file extension
func categorize(name string) string {
    ext := strings.ToLower(filepath.Ext(name))
    switch ext {
    case ".mp4", ".mkv", ".mov", ".avi", ".flv", ".wmv", ".webm":
        return "Video"
    case ".mp3", ".flac", ".aac", ".wav", ".ogg", ".m4a":
        return "Music"
    case ".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", ".xz":
        return "Archives"
    case ".iso", ".img":
        return "Images"
    case ".exe", ".msi", ".apk", ".dmg", ".pkg", ".deb", ".rpm":
        return "Programs"
    case ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt":
        return "Documents"
    case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg":
        return "Pictures"
    default:
        return ""
    }
}

// SetStartAt sets or clears a task's scheduled start time. Zero value clears scheduling.
func (m *Manager) SetStartAt(id string, at time.Time) error {
    m.mu.Lock()
    defer m.mu.Unlock()
    t, ok := m.tasks[id]
    if !ok { return errors.New("task not found") }
    t.StartAt = at
    t.UpdatedAt = time.Now()
    // if scheduled for future and currently running, pause back to waiting
    if !t.StartAt.IsZero() && time.Now().Before(t.StartAt) {
        if t.Status == StatusRunning && t.cancel != nil {
            t.cancel()
            if m.running > 0 { m.running-- }
        }
        if t.Status != StatusCompleted { t.Status = StatusWaiting }
    }
    go m.schedule()
    return nil
}
